Repository navigation
feat(guardrails): add Airia guardrail integration - #40784
Conversation
226f0a4 to
612ca2c
Compare
|
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 3 · PR risk: 0/10 |
46d65e4 to
7babb5b
Compare
|
@greptileai please re-review at 7babb5b: fixes the AttributeError on a non-dict body, the rewrite length-mismatch, replaces the flag-only streaming test with one that drives the real streaming iterator and checks client-visible output, and switches the HTTP client to constructor-based dependency injection. The tool-call streaming timing gap and the tool-call-only response skip are analyzed in detail in the PR description under Caveats; no fix is available from this guardrail alone without regressing streamed redaction. |
7babb5b to
899e719
Compare
|
@yassin-berriai @yuneng-berri @yucheng-berri Can you help us review this PR, to add Airia as a Guardrail integration. We've addressed the previous review feedback and verified that all tests pass locally. Our Docs PR is linked here: BerriAI/litellm-docs#1427 |
|
@greptileai review latest head |
|
bugbot run |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Co-authored-by: Airia Open Source <opensource@airia.com> Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Hi @airia-open-source, can you send me the relevant longstanding credentials e.g api key for airia guardrail through email so that I can verify this PR e2e? Also we require them for maintaining the integration going forward. |
899e719 to
4747b6a
Compare
|
Rebased onto
Locally on this head: 34/34 in @greptileai please re-review at 4747b6a. |
… not sent Bugbot finding on BerriAI#40784. An INTERVENED verdict carrying `structured_messages: []` with `texts` omitted passed `_rewritten` unchanged: an empty list has the same length as an omitted field, so it counted as an applied rewrite while the `texts` that actually carried the content went through untouched. post_call sends only `texts`, so this was reachable. A rewrite for a field the request did not send is now a mismatch, same as a wrong length, and the verdict blocks. Regression test covers both the empty and the non-empty case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…write cannot reach Veria finding on BerriAI#40784. incremental_diff, the mode that carries a rewrite into the stream, exists only for OpenAI chat completions; UnifiedLLMGuardrails sends every other streamed route (/v1/responses, /v1/messages) to block_only, which streams the original text live and drops rewrites. So with post_call on, a streamed Responses or Anthropic call could receive text that Airia then blocked or redacted. Two changes, both confined to this guardrail: - streaming_buffer_until_moderated is set, so on the block_only fallback the framework holds every chunk until the end-of-stream verdict instead of releasing it live. The flag is never read on the incremental_diff path, so streamed chat completions are unchanged. - A REDACT verdict on a streamed response whose route cannot carry a rewrite is enforced as a block, since block_only would otherwise replay the original. The route check mirrors _resolve_transform_call_type so the two cannot disagree; an unresolvable route fails closed. Request-side rewrites and non-streamed calls are untouched. The end-to-end test drives the real UnifiedLLMGuardrails iterator over an Anthropic /v1/messages SSE stream and asserts not one original chunk is released for either verdict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Streamed chat completions deliver redactions at end of stream; streamed Responses and Anthropic Messages calls cannot carry a rewrite, so the proxy holds the response and enforces a Redact outcome as a Block (BerriAI/litellm#40784 review). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@greptileai please re-review at 74daaf4. Since 4747b6a: Veria's non-chat streaming finding is fixed (buffered block_only fallback, redact enforced as block where the stream cannot carry a rewrite), with an end-to-end Anthropic-stream test; Veria passes on this head. 46/46 in test_airia.py, 220/220 in the shared guardrail suites, ruff strict and basedpyright gates pass locally. The 20 workflow runs still await maintainer approval. |
Adds `airia` as a built-in guardrail provider. The hook sends the prompt under `pre_call` and the model's response under `post_call` to the Airia AI Gateway, which answers allow, block, or redacted content; the only configuration is `api_base`, `api_key`, and an optional `timeout`. `during_call` is not offered: it runs concurrently with the model call, so a block could land after the prompt has already reached the provider. A BLOCKED verdict, or any action this version does not recognise, raises with `blocked_content=True`; a transport error, a non-2xx, or a response body that is not a JSON object raises with `blocked_content=False`, so callers can tell "could not evaluate" from "evaluated and blocked" while both still fail closed. On GUARDRAIL_INTERVENED a copy of the inputs is returned with every rewritten field substituted; an intervention carrying no applicable rewrite, a rewrite of the wrong shape, or a rewrite whose item count does not match what was sent (which would misalign the positional write-back downstream) blocks instead of letting the original through. Streamed responses are moderated whole and then emitted redacted: the hook opts into the unified hook's `incremental_diff` mode (the default `block_only` drops rewrites) with `streaming_end_of_stream_only`, so a redaction can never span transform rounds and underflow. Covered by a test that drives the real streaming iterator hook end to end and checks the assembled client output, not just the guardrail's own config attributes. The HTTP client is dependency-injected (an `async_handler` constructor parameter, defaulting to a real client) rather than mutated post-construction, so tests inject a fake client instead of monkeypatching an instance method. Known limitation, not fixable from this guardrail alone: LiteLLM's unified guardrail framework yields a tool-call chunk to the client immediately during `incremental_diff` streaming, inspecting the assembled tool calls against the guardrail's verdict only once the stream ends (`_run_incremental_transform_stream` in `unified_guardrail.py`). A blocked tool call can therefore reach an executing client before the terminal error arrives. Switching to `block_only` with `streaming_buffer_until_moderated` does not close this without a regression: that flag has no effect under `incremental_diff` (the mode branches away before it is read), and `block_only` itself replays the original, unredacted chunks on release, silently dropping any REDACT verdict on streamed text. Relatedly, LiteLLM's OpenAI chat completions guardrail translation skips `post_call` guardrails entirely for a response with no text content, so a tool-call-only response bypasses this guardrail (and every other post-call guardrail) even outside streaming. Both are shared-framework behavior, not specific to Airia; fixing either is out of scope for an isolated single-guardrail contribution.
… not sent Bugbot finding on BerriAI#40784. An INTERVENED verdict carrying `structured_messages: []` with `texts` omitted passed `_rewritten` unchanged: an empty list has the same length as an omitted field, so it counted as an applied rewrite while the `texts` that actually carried the content went through untouched. post_call sends only `texts`, so this was reachable. A rewrite for a field the request did not send is now a mismatch, same as a wrong length, and the verdict blocks. Regression test covers both the empty and the non-empty case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…write cannot reach Veria finding on BerriAI#40784. incremental_diff, the mode that carries a rewrite into the stream, exists only for OpenAI chat completions; UnifiedLLMGuardrails sends every other streamed route (/v1/responses, /v1/messages) to block_only, which streams the original text live and drops rewrites. So with post_call on, a streamed Responses or Anthropic call could receive text that Airia then blocked or redacted. Two changes, both confined to this guardrail: - streaming_buffer_until_moderated is set, so on the block_only fallback the framework holds every chunk until the end-of-stream verdict instead of releasing it live. The flag is never read on the incremental_diff path, so streamed chat completions are unchanged. - A REDACT verdict on a streamed response whose route cannot carry a rewrite is enforced as a block, since block_only would otherwise replay the original. The route check mirrors _resolve_transform_call_type so the two cannot disagree; an unresolvable route fails closed. Request-side rewrites and non-streamed calls are untouched. The end-to-end test drives the real UnifiedLLMGuardrails iterator over an Anthropic /v1/messages SSE stream and asserts not one original chunk is released for either verdict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Streamed chat completions deliver redactions at end of stream; streamed Responses and Anthropic Messages calls cannot carry a rewrite, so the proxy holds the response and enforces a Redact outcome as a Block (BerriAI/litellm#40784 review). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… one statement CI's test-tree ruff run flags PT012 on the non-chat streaming test: the `async for` that collected released chunks sat inside `pytest.raises`, so a raise from the loop body would be indistinguishable from the one the test is about. The loop now lives in a `_drain_into` helper and the `with` block awaits it alone. Same assertion, same partial-leak capture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sedpyright ceilings scripts/type_check_gate.py now gates every rule on its merge-base count (BerriAI#40193), and the guardrail added sixteen strict-rule errors it previously absorbed under the global budget. - The parsed gateway response goes through `_as_object` once, so `action`, `blocked_reason` and the rewrite fields are read from `dict[str, object]` rather than `dict[Unknown, Unknown]`; `blocked_reason` is only used as the message when it is a non-empty string. - `get_config_model` returns the concrete `type[AiriaGuardrailConfigModel]`, and the config model carries the same pyright ignore as its siblings for the unparameterised base. - The remaining untyped upstream members (the decorator, the client factory, `super().__init__`'s kwargs, the handler's `post`) are ignored per line with the reason, as the Wingback guardrail does. The registry reads `guardrail["guardrail_name"]`, a required TypedDict key. - Tests reach the injected mock and the handler timeout through typed helpers instead of the real handler's partially typed attributes. No behaviour change: 46/46 tests, ruff default and strict, and both lint gates pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cec8690 to
c35af65
Compare
|
Rebased onto
Locally on this head: 46/46 in @greptileai please re-review at c35af65. |
* docs: add Airia guardrail integration page * docs: state how Airia handles streamed responses per route Streamed chat completions deliver redactions at end of stream; streamed Responses and Anthropic Messages calls cannot carry a rewrite, so the proxy holds the response and enforces a Redact outcome as a Block (BerriAI/litellm#40784 review). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
@litereview full |
✅ fullScope: full · Full PR review. Completed · Some checks not run. Verdict: no actionable defects foundThe PR adds one new provider, What I traced
Note on the PR bodyThe PR body says the test file is Checks and execution details
View review · Reviewed |
…lations Drop the mutable-ok comments that suppress nothing (LIT013), give the two deferred-import noqa lines a reason (LIT003), and annotate the parsed JSON object as Mapping instead of dict (LIT001).
|
@BugBot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e8ef423. Configure here.
| return True | ||
| proxy_request: Final = _as_object(request_data.get("proxy_server_request")) | ||
| body: Final = _as_object(proxy_request.get("body")) if proxy_request is not None else None | ||
| return body is not None and body.get("stream") is True |
There was a problem hiding this comment.
Stream detection misses passthrough marker
Medium Severity
_is_streamed only looks for stream is True on request_data or proxy_server_request.body. The rest of the guardrail stack treats a request as streamed when SERVER_STREAMING_CLASSIFICATION_MARKER is present, which Bedrock and other passthrough routes stamp without ever setting stream. On those routes a GUARDRAIL_INTERVENED response is returned as a rewrite, block_only drops it, and the buffered original text is released.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit e8ef423. Configure here.
There was a problem hiding this comment.
Checked this live against the real Airia gateway with real Bedrock and Gemini, on main + this PR (e8ef423) and on main + #46307 + this PR. None of the marker-only streams reach the block_only drop-and-replay path:
- Bedrock
converse-stream: on main the stream is buffered and post_call runs throughpost_call_success_hookon a synthetic Converse response, and the guardrail's texts are written back into the frames (de_anonymize_event_stream). With fix(bedrock)!: stream Converse passthrough frames through post_call guardrails instead of buffering #46307 the relay's Converseprocess_output_streaming_responsewrites the rewrite back into the held frames. Live result on both trees: the stream carries[EmailAddress1], no raw email. - Bedrock
invoke-with-response-streamand generic pass-through streams (e.g./gemini/...:streamGenerateContent): post_call guardrails don't run on these at all, they are relayed raw. With Airia pointed at a dead host these still return 200, soapply_guardrailis never called. That gap is pre-existing and applies to every guardrail, so it is tracked separately rather than in this PR. - Native routes that stream by endpoint (
:streamGenerateContenton the Google routes) setdata["stream"] = True, so_is_streamedalready sees them.
Treating the marker as streamed would make things worse: on converse-stream the passthrough route has no call type, so _stream_cannot_carry_a_rewrite would return True and turn a redaction that is delivered correctly today into a block. Leaving _is_streamed as is.
yucheng-berri
left a comment
There was a problem hiding this comment.
LGTM, I've tested the integration live e2e.
The Airia guardrail (BerriAI#40784) shipped without a dashboard presence: no card in the Guardrail Garden and no logo next to its name in the provider list and guardrail table, which every other partner guardrail has. This adds both, following the Wingback and Conduct entries: - `airia.svg`, Airia's brand mark from AiriaLLC/airia-public-assets, under the bundled logos. - A `guardrailLogoMap` entry keyed by the config model's `ui_friendly_name` ("Airia Guardrail"), which is what the provider dropdown and table look the logo up by. - A partner card and an `airia` preset. The preset's `provider` is the camel-cased registry key ("Airia"), the form the dashboard derives for every provider it reads from `/guardrails/ui/provider_specific_params`. The preset seeds both `pre_call` and `post_call`, as the docs example does, so a response-side redaction is not silently left out. - The logo-file assertion in `guardrail_garden_data.test.ts` covers the new card. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…go (#46562) The Airia guardrail (#40784) shipped without a dashboard presence: no card in the Guardrail Garden and no logo next to its name in the provider list and guardrail table, which every other partner guardrail has. This adds both, following the Wingback and Conduct entries: - `airia.svg`, Airia's brand mark from AiriaLLC/airia-public-assets, under the bundled logos. - A `guardrailLogoMap` entry keyed by the config model's `ui_friendly_name` ("Airia Guardrail"), which is what the provider dropdown and table look the logo up by. - A partner card and an `airia` preset. The preset's `provider` is the camel-cased registry key ("Airia"), the form the dashboard derives for every provider it reads from `/guardrails/ui/provider_specific_params`. The preset seeds both `pre_call` and `post_call`, as the docs example does, so a response-side redaction is not silently left out. - The logo-file assertion in `guardrail_garden_data.test.ts` covers the new card. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>


TLDR
Problem this solves:
How it solves it:
airiaas a built-in guardrail provider, configured withapi_base+api_keypre_call), responses after it (post_call)incremental_diff+streaming_end_of_stream_only)User Flow
Before: a proxy admin who wants Airia guardrails cannot reference the provider by name
guardrail: airiaunderguardrails:inconfig.yamland start the proxySkipping guardrail 'airia': invalid configuration ... Unsupported guardrail: airiahttp://litellm-domain/v1/chat/completionsreaches the model uncheckedAfter: the same config enforces the Airia policy on every call
guardrail: airiaunderguardrails:inconfig.yamland start the proxy/v1/chat/completionswith"my aws key is AKIAIOSFODNN7EXAMPLE"returns400—Guardrail raised an exception, Guardrail: airia, Message: <the message configured on their Airia guardrail>. The model is never called"Contact me at ada@example.com"returns200; the model receivedContact me at [EmailAddress1]200unchangedRelevant issues
—
Pre-Submission checklist
uv run pytest tests/test_litellm/proxy/guardrails/guardrail_hooks/test_airia.py -v→ 21 passedScreenshots / Proof of Fix
Setup shared by both runs.
config.yaml:The guardrail side is a live Airia deployment, not a mock. The model is a local echo endpoint because that is the only way to show what the provider actually received; nothing in the guardrail path is stubbed.
Before (a426dc4)
litellm --config config.yaml --port 4096After (899e719, unit-tested; see Caveats for what changed since the run below)
(rebased onto the current base after the run below; the tree is byte-identical to the commit it was captured on)
Blocked — secret in the prompt
HTTP 400{"error": {"message": "Guardrail raised an exception, Guardrail: airia, Message: Secret blocked by A51-317 demo policy.", "type": "None", "param": "None", "code": "400"}}Redacted — PII in the prompt
HTTP 200,choices[0].message.content = "echo: Contact me at [EmailAddress1]"['Contact me at [EmailAddress1]']— the address never reached itAllowed — benign prompt
HTTP 200,choices[0].message.content = "echo: Explain recursion in one sentence."Redacted — PII in the model's response (
post_call)The echo endpoint answers the fixed prompt
please share the demo contact(no PII on the way in) withSure, reach me at ada@example.com or call 415-555-0199.HTTP 200,choices[0].message.content = "echo: Sure, reach me at [EmailAddress1] or call 415-555-0199."Redacted — PII in a streamed response (
post_call,stream: true)HTTP 200; the stream is held until the response is complete and Airia has answered, then one delta carriesecho: Sure, reach me at [EmailAddress1] or call 415-555-0199.grep -c 'ada@example.com'over the raw SSE bytes:0Type
🆕 New Feature
Caveats (if any)
Medium
_run_incremental_transform_streaminunified_guardrail.pyyields a passthrough chunk for each tool-call delta as it arrives and only inspects the assembled tool calls against the guardrail's verdict once the stream ends, so a blocked tool call can be executed by the client before the terminal error lands. This is shared framework behavior, not specific to Airia: every guardrail usingincremental_diffstreaming has the same exposure. I looked for a way to close it from this guardrail alone and could not find one that does not regress the redaction path:streaming_buffer_until_moderated(which withholds every chunk until the full response is moderated) has no effect underincremental_diff— the mode branches away and returns before that flag is ever read — and switching toblock_onlyto make it apply would silently drop REDACT verdicts on streamed text, replaying the original unredacted content on release. Closing this needs a change to the shared streaming framework (buffering tool-call chunks specifically, independent of text transform mode), which is out of scope for an isolated single-guardrail PROpenAIChatCompletionsHandler.process_output_responseskips everypost_callguardrail entirely when a response has no text content (if not self._has_text_content(response): ... return response), so a tool-call-only response with no text bypasses Airia's response-side check regardless of streaming. Also shared framework behavior; also out of scope hereLow
Streamed text arrives in one delta at end of stream rather than incrementally: the trade for redactions that can never underflow. Blocks still terminate the stream immediately
The HTTP client is dependency-injected (
async_handlerconstructor param) rather than mutated after construction, so tests build a fake client instead of monkeypatching an instance methodduring_callis deliberately not offered; it runs concurrently with the model call, so a block could land after the prompt reached the providerThe proof's model is a local echo endpoint; the guardrail path is a live Airia deployment. No provider key is available to this machine for a paid-model run
If Airia is unreachable the request is refused (
blocked_content=False), not passed through — same fail-closed stance as the other guardrail providersDocs page will follow in a separate PR to
litellm-docsBerriAI/litellm retired
litellm_internal_stagingas the trunk while this PR was open (main is now the sole default branch); rebased onto currentmainwith no conflicts, and re-verified against the strict-ruleset rules that were added upstream in the same window (test return-type annotations, bannedtyping.cast,**kwargstyping). No behavior change, no budget-JSON editsFinal Attestation
Note
Medium Risk
Changes sit on the request/response path for all traffic using this guardrail (block/redact and fail-closed on errors), with non-trivial streaming route logic; misconfiguration or gateway outages can deny requests or alter prompts/responses.
Overview
Adds Airia as a first-class LiteLLM proxy guardrail (
guardrail: airia) so admins can configureapi_base,api_key, andtimeout(orAIRIA_GATEWAY_*env vars) instead of a custom dotted-path hook.AiriaGuardrailevaluates pre_call and post_call traffic by POSTing to the Airia gateway (/v1/guardrails/litellm) and maps verdicts to allow, block (GuardrailRaisedException), or redact (rewritestexts/structured_messageswith strict length and field checks so bad rewrites fail closed as blocks). Gateway or transport errors refuse the request withblocked_content=False; unknown actions block.Streaming is tuned for redaction on OpenAI chat completions (
incremental_diff, end-of-stream moderation). On other streamed routes where the framework cannot apply rewrites, GUARDRAIL_INTERVENED on responses is upgraded to a block so unredacted text is not replayed; non-streamed paths still get rewrites.Includes a Pydantic config model for the UI, enum registration, ruff strict ignore for
**kwargsforwarding, and broad unit tests (includingUnifiedLLMGuardrailsstreaming behavior).Reviewed by Cursor Bugbot for commit e8ef423. Bugbot is set up for automated code reviews on this repo. Configure here.